Profile picture

useEffect 제대로 이해하기(1)

Amaranth2024년 06월 23일

이번 세차새차 회의 시간에 코드에서 이해가 안되었던 부분을 질문드리면서, 내가 자바스크립트라는 언어와 리액트의 생명주기, 그리고 useEffect()에 대해 제대로 이해하고 있지 않았다는 걸 알았다.

내가 기억하기로, useEffect()는 컴포넌트가 렌더링 될 때마다 내부 함수를 호출하고, useEffect()의 의존성 배열에 담긴 상태 값이 변하면 컴포넌트가 재렌더링되어 내부 함수가 다시 호출되는 것이라고 생각했었다. 하지만 전혀 아니었고, 지금 생각하면 내가 왜 그렇게 이해하고 있었는지 모르겠다. 처음 공부할 때 잘못 배운 듯 하다. 잘못 알고 있던 지식은 다시 배우면서 바로잡으면 된다. 그래서, useEffect에 대한 개념을 다시 익혀보고자 이 글을 작성하게 되었다.

내용은 공식문서를 참조했다.

Effect로 동기화하기

Effect를 사용하면 컴포넌트가 렌더링된 후 특정 코드를 실행하여 React 외부의 시스템(네트워크, 서드파티 라이브러리 등)과 컴포넌트를 동기화할 수 있다.

Effect의 개념과 이벤트와의 차이점

우리는 컴포넌트 내부에 크게 2가지 종류의 로직을 작성할 수 있다.

  1. 렌더링 코드: 컴포넌트의 최상단에 위치, props와 state를 적절히 변형하여 결과적으로 JSX를 반환한다.

    JSX

    JavaScript를 확장한 문법으로, 마치 자바스크립트와 HTML을 결합한 것과 같이 UI 컴포넌트를 구성할 수 있게 해준다.

    function App() {
      return <h1>Hello, GodDaeHee!</h1>
    }
    
    // 위와 같이 작성하면, 바벨이 다음과 같이 자바스크립트로 해석하여 준다.
    function App() {
      return React.createElement("h1", null, "Hello, GodDaeHee!")
    }
  2. 이벤트 핸들러: 단순한 계산 용도가 아니라 어떤 동작을 수행하는 컴포넌트 내부의 중첩 함수. 이를테면 입력 필드를 업데이트하거나, HTTP 요청을 보내거나, 사용자를 다른 화면으로 리다이렉트 시키는 등의 동작을 수행한다. 특정 사용자 작업에 의해 발생하는 부수효과들이 포함된다.

    하지만 화면이 렌더링되는 것을 트리거 삼아 동작하는 코드를 작성하려면 어떻게 해야할까? 이를테면 화면에 보일 때마다(새로고침될 때마다) 채팅 서버에 접속해야 하는 ChatRoom 컴포넌트가 있을 수 있다. 이는 위 두 가지 방식으로는 구현할 수가 없다. 여기서 Effect가 사용된다.

Effect와 이벤트의 차이점을 이해하기 위해 앞서 설명한 ChatRoom 컴포넌트를 생각해보자. ChatRoom에서 버튼을 클릭하여 메시지를 보내는 것은 이벤트이다. 사용자의 상호작용에 따라 직접적으로 발생하기 때문이다. 하지만 서버 연결 설정은 Effect인데, 컴포넌트의 렌더링을 유발하는 어떤 상호작용에 따라 발생하는 것이 아니기 때문이다. 기본적으로 Effect는 커밋이 끝난 후 화면 업데이트가 이루어지고 나서 실행된다.

커밋?

React가 화면에 컴포넌트를 표시하는 단계는 트리거->렌더링->커밋으로 이루어진다.

  • 렌더링 트리거 : 컴포넌트가 렌더링되는 경우는 컴포넌트가 처음 렌더링 되는 경우(초기 렌더링), 컴포넌트의 state가 업데이트된 경우(리렌더링) 2가지가 있다.
  • React 컴포넌트 렌더링 : 렌더링이 트리거되면 React에서 컴포넌트가 호출된다. 초기 렌더링에서는 루트 컴포넌트가 호출되고, 리렌더링에서는 렌더링을 트리거한 컴포넌트를 호출한다.
    • 업데이트된 컴포넌트가 다른 컴포넌트를 반환한다면, React는 업데이트 된 컴포넌트를 렌더링한 다음에 반환된 컴포넌트를 렌더링한다.
  • React가 DOM에 변경사항을 커밋 : 렌더링 후 React는 DOM을 수정한다. 초기 렌더링의 경우 appendChild()라는 DOM API를 사용해 생성한 모든 DOM 노드를 화면에 표시한다. 리렌더링의 경우 최소한의 작업을 적용해 DOM이 최신 렌더링 출력과 일치하도록 한다.
    • 렌더링 결과가 이전과 같으면 React는 DOM을 건드리지 않는다.

Effect 작성하는 법

Effect 작성은 다음의 세 단계를 따른다.

  1. Effect 선언 : Effect는 기본적으로 commit 이후에 실행된다.
  2. Effect의 의존성을 지정 : 대부분의 Effect는 모든 컴포넌트가 렌더링 된 후 뿐만 아니라, '필요할 때' 다시 실행될 수 있어야 한다.
  3. (필요한 경우) 클린업 함수 추가 : 수행 중이던 작업을 중지&취소하는 방법을 지정해주어야 할 때가 있다.

Effect 선언

import { useEffect } from "react"

function MyComponent() {
  useEffect(() => {
    // 이곳의 코드는 *모든* 렌더링 후에 실행된다.
  })

  return <div />
}

컴포넌트 내에서 Effect를 선언하기 위해서는 React에서 useEffect 훅을 임포트해야한다. 그리고 컴포넌트 최상위 레벨에서 훅을 호출하고, Effect 내부에 함수 코드를 넣어야 한다. 컴포넌트가 렌더링될 때마다 React는 화면을 업데이트한 다음 useEffect 내부의 코드를 실행한다.

이러한 이유로, 다음과 같은 코드는 무한 반복이 발생할 수 있다.

const [count, setCount] = useState(0)
useEffect(() => {
  setCount(count + 1)
})

초기 렌더링 후 useEffect 내부 코드가 실행되어 count라는 state 값이 업데이트되고, state가 변경됨에 따라 컴포넌트가 다시 렌더링되고, 컴포넌트가 렌더링됨에 따라 useEffect 내부 코드가 다시 실행되고...이러한 문제를 해결하기 위해 우리는 Effect의 의존성을 지정해줄 수 있다.

Effect의 의존성 지정

한 컴포넌트에서 관리하는 state가 여러 개일 때, 어떤 Effect 코드는 특정 state가 변경되었을 때만(=특정 state가 변경되어 렌더링이 일어났을 때만) 실행하고 싶을 수 있다. 이 경우 해당 state를 Effect의 의존성으로 지정해주면 되는데, 의존성을 지정하는 방법은 다음과 같이 useEffect의 2번째 인자로 넣어주는 배열(=의존성 배열)에 state를 넣어주는 것이다.

useEffect(() => {
  if (isPlaying) {
    console.log("video.play() 호출")
    ref.current.play()
  } else {
    console.log("video.pause() 호출")
    ref.current.pause()
  }
}, [isPlaying])

만약 의존성 배열을 빈 배열로 지정해줄 경우, 컴포넌트가 렌더링될 때(마운트될 때)만 Effect가 실행된다.

(필요한 경우)클린업 함수 추가

여기서, 컴포넌트가 마운트되고 언마운트된다는 게 대체 무슨 의미일까? 컴포넌트가 마운트된다는 것은 리액트 컴포넌트의 생명주기 중, DOM 객체가 처음으로 생성되어 브라우저에 나타나는 것을 의미한다. 그럼 마운트는 렌더링과 무엇이 다른가? 마운트는 렌더링 과정을 포함하는 과정이다. 다시말해 컴포넌트가 마운트되면 리액트가 처음으로 컴포넌트를 렌더링하고, 실제로 초기 DOM을 빌드한다. 반대로 언마운트는 컴포넌트가 DOM에서 제거되는 것을 의미한다. 이쯤 하고 리액트 컴포넌트의 생명주기의 디테일한 내용은 다른 글에서 다루도록 하고, 이제 클린업 함수에 대해 알아보자.

채팅방 컴포넌트가 있다고 할 때, 컴포넌트가 사용자에게 표시되는 동안에만 채팅서버와의 연결을 유지하고자 한다면 어떻게 해야 할까? 이 말 컴포넌트가 마운트될 때 채팅서버와 연결하고 컴포넌트가 언마운트 되면 채팅서버와의 연결을 끊어야 할 것이다. 컴포넌트가 마운트될 때 채팅서버와 연결하는 것은 다음과 같은 코드로 구현할 수 있다.

import { useEffect } from "react"
import { createConnection } from "./chat.js"

export default function ChatRoom() {
  useEffect(() => {
    const connection = createConnection()
    connection.connect()
  }, [])
  return <h1>채팅에 오신걸 환영합니다!</h1>
}

하지만 이 코드는 컴포넌트가 언마운트됐을 때의 코드가 포함되어 있지 않다. 이 경우 채팅방 컴포넌트가 마운트된 페이지에서 다른 페이지로 이동한 뒤(채팅방 컴포넌트는 언마운트된다) 뒤로가기를 통해 다시 채팅방 컴포넌트를 마운트하게 되면 채팅방 컴포넌트가 2번의 마운트되어 채팅서버와의 연결이 2개가 생기는 문제가 발생할 수 있다.

의존성 배열이 빈 배열이면 Effect 코드가 꼭 1번만 실행이 될까?

앞서 언급된 문제(채팅방 컴포넌트가 여러 차례 마운트될 때의 문제)가 발생했을 때 빠르게 파악할 수 있도록, React는 개발 모드에서 초기 마운트 후 모든 컴포넌트를 다시 한 번 마운트한다. 그래서 개발모드에서라면 의존성 배열이 빈 배열이더라도 Effect 코드는 2번씩 실행된다.

이 문제를 해결하기 위해선, Effect에서 클린업 함수를 반환하면 된다.

useEffect(() => {
  const connection = createConnection()
  connection.connect()
  return () => {
    connection.disconnect()
  }
}, [])

클린업 함수는 Effect가 다시 실행되기 전마다 호출되고, 컴포넌트가 언마운트될 때에도 마지막으로 호출된다.

Q. 의존성 배열에는 어떤 값이 들어가야 할까?

useEffect() 인자 함수에서 사용되는, 컴포넌트 props로 전달받은 변수와 useState로 생성한 상태 변수

function VideoPlayer({ src, isPlaying }) {
const ref = useRef(null);

useEffect(() => {
	if (isPlaying) {
	ref.current.play();
	} else {
	ref.current.pause();
	}
}, [isPlaying]);

위 코드에서, useRef로 정의된 ref 객체와 isPlaying이라는 state가 Effect 코드에서 사용되었지만 의존성으로는 isPlaying만 넣어준 것을 볼 수 있다.

왜 ref는 의존성으로 넣어주지 않았을까? 이는 ref 객체가 안정된 식별성(stable identity) 을 가지기 때문이다. React는 동일한 useRef 호출에서 항상 같은 객체를 얻을 수 있음을 보장한다. 이 객체는 절대 변경되지 않기 때문에, 자체적으로 Effect를 다시 실행시킬 일이 없다. 때문에 ref는 의존성 배열에 포함해도, 포함하지 않아도 상관이 없다.

useState로 반환되는 set 함수들도 안정된 식별성을 가지기 때문에, 일반적으론 이러한 함수들도 의존성에서 생략된다.

다만 한 가지 주의해야 할 점은, 해당 객체가 안정적임을 알 수 있는 경우에만 의존성 배열에서 생략할 수 있다. 만약 ref가 부모 컴포넌트로부터 전달되었다면 의존성 배열에 명시해야 한다. 이는 부모 컴포넌트가 항상 동일한 ref를 전달하는지, 여러 ref 중 하나를 조건부로 전달하는지 알 수 없기 때문이다.


Loading script...